overcurried

안전한 any 타입 만들기

February 05, 2020

/

🍛🍛🍛

타입스크립트에서 가장 유용한 타입은 무엇일까요? 저는 any 라고 생각합니다. 항상 타입 검사를 만족시킨다는 특성이 타입스크립트에서도 자바스크립트 모듈을 손쉽게 사용할 수 있게 해주기 때문입니다. 이렇게 자바스크립트의 거대한 생태계를 그대로 활용할 수 있게 해준다는 점에서, any는 타입스크립트의 생산성을 높여주는 유용한 타입입니다.

declare const untypedModule: any;

그럼 타입스크립트에서 가장 유용하지 않은 타입은 무엇일까요? 저는 이 또한 any라고 생각합니다. 항상 타입 검사를 만족시킨다는 특성이 타입 검사의 의의를 퇴색시키기 때문입니다. 어떤 비정상적인 연산이라도 any 타입이 붙어버리면 타입 검사를 통해 걸러낼 수 없기 때문에 any는 프로그램의 안전성을 낮추는 유용하지 않은 타입입니다.

('something' as any) * 10;

마치 양날의 검 같군요, 잘 쓰면 빠르게 프로덕트를 개발할 수 있지만 자칫 잘못 쓰면 되려 버그 지옥에 빠지게 되니까요. 버그 지옥에 빠질 일 없이, 안전하게 any를 쓰는 방법은 없을까요?

있습니다! 그것도 안전한 any를 구현하는 방법이요! 지금부터 알려드리도록 하겠습니다.

안전한 any란?

먼저, 가장 중요한 질문을 던져 보도록 하겠습니다. 안전한 any란 무엇일까요? any의 어떤 성질이 any를 위험하게 만들고 어떤 성질이 any를 가치있게 만들까요?

이 질문에 답하려면 먼저 서브타입 관계(subtype relation)와 탑 타입(top type)을 알아야 합니다.

서브타입 관계

다른 한 타입을 포함하는 타입슈퍼타입(supertype)이라고 하고, 슈퍼타입에 포함되는 타입서브타입(subtype)이라고 합니다. ‘타입을 포함한다’의 기준은 타입 시스템 별로 다르지만 구조적 타입 시스템(structural type system)을 가진 타입스크립트의 경우, 한 타입이 다른 한 타입의 값을 모두 포함하고 있으면 그 타입을 포함한다고 합니다.

type Supertype = { x: boolean }
type Subtype = { x: boolean, y: number }

위 코드에서, Supertype의 값은 타입이 boolean인 프로퍼티 x를 가진 객체입니다. Subtype의 값은 마찬가지로 타입이 boolean인 프로퍼티 x를 가지지만 동시에 타입이 number인 프로퍼티 y도 가지는 객체이지요.

유심히 살펴보니 Subtype의 값은 타입이 boolean인 프로퍼티 x를 가지는 객체이기도 합니다. 즉, 모든 Subtype의 값은 Supertype의 값이기도 한 것이지요. 이렇게 SupertypeSubtype을 포함하기 때문에, SupertypeSubtype의 슈퍼타입이고 SubtypeSupertype의 서브타입이라고 할 수 있습니다.

이러한 슈퍼타입과 서브타입, 두 타입 간의 포함 관계를 서브타입 관계라고 하며 <:를 통해 서브타입 <: 슈퍼타입 형식으로 표현합니다.

 Subtype <: Supertype
 number <: number | string

심화 문제 #1
StarWars <: Movie <: Entertainment라고 할 때, (starWars: StarWars) => Entertainment(movie: Movie) => Movie의 서브타입인가요? 그리고 왜 그렇게 생각하셨나요?

탑 타입

서브타입 관계를 따질 수 있는 타입 시스템에는 탑 타입이라 불리는 특별한 타입이 존재합니다.

탑 타입모든 타입의 슈퍼타입으로 모든 타입의 값을 값으로 갖지만, 그렇기 때문에 모든 타입의 값에 대해 공통적으로 할 수 있는 연산 외에는 그 어떤 연산도 할 수 없다는 점이 특징입니다. 타입스크립트에서 탑 타입은 unknown입니다.

let top: unknown = 'a';
top = {};
top = 1;

top + 1; // Wrong!

심화 문제 #2
왜 탑 타입의 값에 대해서는 모든 타입의 값에 대해 공통적으로 할 수 있는 연산 외에 어떤 연산도 할 수 없을까요?

모순적인 타입, any

any는 모든 타입의 슈퍼타입이기 때문에 탑 타입입니다. 그렇지만, any는 탑 타입의 ‘탑 타입의 값에는 모든 타입의 값에 적용 가능한 연산 외에 어떤 연산도 적용할 수 없다’라는 특징을 가지고 있지 않습니다. 이게 과연 가능한 일일까요?

아니요, 전혀 그렇지 않습니다. 탑 타입은 모든 타입의 값을 갖기 때문에 안전한 타입이 되려면 당연히 모든 타입의 값에 적용 가능한 연산만 적용할 수 있어야 함이 마땅하기 때문이죠. 즉, 탑 타입이면서 탑 타입의 특징을 가지고 있지 않은 타입의 존재는 모순 그 자체이며, 그렇기에 any는 위험한 타입입니다.

any의 가치

그럼 타입스크립트는 왜 이런 모순적인 타입을 가지고 있는 걸까요? 그 이유는 타입스크립트의 디자인 목표에서부터 추측할 수 있습니다.

타입스크립트의 개발 목표는 철옹성과 같은 안전한 타입 시스템을 도입해 자바스크립트에서 발생할 수 있는 모든 오류를 걷어내는 데 있는 게 아니라, 자바스크립트의 생산성을 보전하면서 오류가 될 수 있는 코드들을 걸러주는 거름망 같은 타입 시스템을 도입하는 데 있기 때문에 any와 같이 안전성을 해치지만 생산성을 보전하는 데에 도움이 되는 타입을 만들었다고 생각합니다.

그렇기에 any의 가치는 자바스크립트 코드를 그대로 사용할 수 있게 함으로서 타입스크립트의 생산성을 높여주는 데 있다고 할 수 있겠습니다.


다시 본론으로 돌아가, 계속해서 안전한 any를 정의해 보도록 하지요.

안전한 any는 그리 거창한 게 아닙니다. any의 가치를 보전하면서 any를 위험하게 만드는 성질을 제거한 타입을 안전한 any라고 할 수 있겠지요.

any를 위험하게 만드는 성질은 any가 탑 타입이면서 탑 타입의 특징을 가지지 않는다는 성질이고, any의 가치는 모든 자바스크립트 코드를 수용할 수 있다는 점에 있는데 이 특징은 any가 탑 타입이라는 성질에서 비롯된 것이니 탑 타입의 특징을 제대로 가지고 있는 any가 안전한 any라고 할 수 있겠습니다.

이렇게, 안전한 any가 무엇인지 정의하였으니 지금부터는 안전한 any를 만들기 위한 지식을 익히고 안전한 any를 만들어 보도록 하겠습니다.

제네릭

제네릭(generic)은 특정 개념의 정의에 타입 매개변수(type parameter)를 포함시킬 수 있게 해주는 기능입니다. 여러 타입을 가질 수 있는 일반적인 개념의 타입을 하나로 제한하지 않고, 여러 타입을 가질 수 있게 하기 위해 사용되는 기능입니다.

const identity: <A>(a: A) => A // 타입 A와, 그 타입의 값 a를 받고 A 타입의 값을 반환하는 함수
  = a => a;

identity<number>(1); // 1 as number
identity<boolean>(true); // true as boolean

제네릭에 대한 다른 해석

앞서 보여드린 예시의 identity 함수는 타입 A와 그 타입의 값인 a를 받아 A 타입의 값을 반환하는 함수입니다. 하지만 모든 타입 A에 대해 (a: A) => A 타입을 갖는 함수가 존재한다는 선언으로도 볼 수도 있습니다.

제네릭에 대한 다른 해석은 다음과 같이 의사 코드로 표현할 수 있습니다.

const identity: <forall A>(a: A) => A // 모든 타입 A에 대해 (a: A) => A 타입을 갖는 함수
  = a => a;

여기서 forall모든 타입(A)에 대해 개념((a: A) => A 타입의 함수)이 존재함을 선언한다고 하여 forall보편 양화사(universal quantifier, a.k.a )라고 부릅니다. 또한, 이렇게 보편 양화사를 통해 표현된 타입의 값은 무한한 곱 타입(product type)이나 교차 타입(intersection type)의 값으로도 볼 수 있습니다.

const identity: 
  & ((a: number) => number)
  & ((a: boolean) => boolean)
  & ((a: string) => string)
  & ... 
  = a => a;

심화 문제 #3
무한한 곱 타입의 값으로 identity 함수를 표현해 보세요. 무한한 교차 타입의 값으로 표현된 경우와 같다고 할 수 있나요? 왜 그렇게 생각하셨나요?

심화 문제 #4
<forall A>(a: A) => void 타입과 (a: <forall A>A) => void 타입은 같은가요? 같다면 같은 이유를, 다르다면 다른 이유를 설명해 보세요.

심화 문제 #5
<forall A>() => A 타입과 () => <forall A>A 타입은 같은가요? 이 문제 또한 같다면 같은 이유를, 다르다면 다른 이유를 설명해 보세요.

보편 양화사의 짝

대수 데이터 타입(algebraic data type)에서 곱 타입의 짝이 합 타입(sum type)인 것 처럼, 보편 양화사에게도 무한한 합 타입으로 나타낼 수 있는 짝이 있습니다. 바로 for some 혹은 there exist라고도 불리는 존재 양화사(existential quantifier, a.k.a )이지요. 존재 양화사는 보편 양화사와 달리 모든 타입이 아닌 어떤 타입에 대해 개념이 존재함을 나타내는 양화사입니다.

identity 함수의 forall을 존재 양화사를 나타내는 forsome으로 바꾸어 봅시다.

const identity2: <forsome A>(a: A) => A // 어떤 타입 A에 대해 (a: A) => A 타입을 갖는 함수
  = a => a;

외형적으로는 forallforsome으로 바뀌었다는 점 외에는 차이가 없습니다만, 의미론적으로 둘 사이에는 큰 차이가 존재합니다. 바로 identity2 함수는 호출될 수 없다는 점이지요.

이 사실은 identity2 함수의 타입을 무한한 합 타입(sum type)으로 표현하면 더욱 잘 드러납니다.

const identity2: 
  | ((a: number) => number)
  | ((a: boolean) => boolean)
  | ((a: string) => string)
  | ... 
  = a => a;

보시다시피, identity2 함수는 모든 타입의 값을 받을 수 있는 identity 함수와 달리 모든 타입의 값만을 받을 수 있습니다.

identity2의 타입과 같이 존재 양화사를 통해 정의된 타입을 existential type이라 부르는데요, 이런 타입들은 한 가지 문제를 가지고 있습니다. 바로 한번 existential type으로 업캐스팅(upcasting)을 하면 타입 시스템이 이전 타입을 잊어 버리기 때문에 다시는 다운캐스팅(downcasting)을 할 수 없다는 점입니다.

다시 다운캐스팅 될 수 없기 때문에 existential type으로 업캐스팅 된 값에는 더 제한적인 연산, 즉 existential type을 무한한 합 타입으로 보았을 때 existential type을 구성하는 모든 타입에 대해 가능한 연산만 수행할 수 있습니다. 이러한 제약을 반영해, existential type의 값에 대해 함수를 적용하는 연산은 일반적인 값들과 달리 $pipe 함수와 같은 부류의 함수가 아닌 eliminator라는 부류의 함수로 추상화되곤 합니다.

구체적으로, eliminator는 처리하고자 하는 값과 그 값의 타입을 구성하는 모든 타입의 값을 처리할 수 있는 함수를 받아, 그 값을 함수에 적용하여 얻은 결과를 반환하는 함수입니다. 위 identity2 함수와 같은 함수들을 처리하는 eliminator는 아래와 같이 정의할 수 있습니다.

const elimIdentityFunction: <forall R>(id: <forsome A>(a: A) => A, f: <forall A>(x: (a: A) => A) => R) => R
  = (id, f) => f(id);

심화 문제 #6
elimIdentityFunction(identity2, identity)는 실행 가능한 코드인가요? 왜 그렇게 생각하셨나요?

안전한 any 만들기

지금까지가 안전한 any를 만들기 위해 필요한 배경 지식이었습니다. 이제부터 안전한 any를 만들어 보도록 하지요.

우리의 목표인 안전한 any는 모든 타입의 슈퍼타입, 즉 탑 타입입니다. 그렇기에 모든 타입을 포함해야 하고, 이는 아래와 같이 합 타입을 통해 표현할 수 있겠습니다.

type SafeAny = number | boolean | string | ...;

합 타입을 보면 무언가가 떠오르지 않으시나요? 앞서 무한한 합 타입으로도 취급할 수 있는 것에 대해 이야기했었잖아요. 네! 존재 양화사요!

그러고 보니 타입스크립트에서 타입은 무한히 존재합니다. 이는 배열 타입을 통해 쉽게 증명할 수 있지요.

type ArrayOfNumber = Array<number>
type ArrayOfArrayOfNumber = Array<ArrayOfNumber>
type ArrayOfArrayOfArrayOfNumber = Array<ArrayOfArrayOfNumber>
type ArrayOf...ArrayOfNumber = Array<ArrayOf...ArrayOfNumber>

어떤 자연수 n에 대해 n차원 배열 타입이 존재할 때, n+1 차원의 배열 타입이 존재하며, 1차원의 배열 타입은 항상 존재하니 귀납적으로 모든 자연수에 대해 그 자연수를 차원으로 하는 배열 타입이 존재함을 알 수 있지요. 또한 정수는 무한하니 배열 타입이 무한히 존재함을 이를 통해 알 수 있지요.

다시 탑 타입으로 돌아가 이야기를 계속하자면, 타입 시스템에 있는 타입이 무한하다면 탑 타입은 무한한 합 타입으로 표현될 수 있다는 이야기입니다. 즉, 존재 양화사를 이용해서 탑 타입을 정의할 수 있다는 말이지요.

type SafeAny = <forsome A>A

이것이 바로 탑 타입이자 우리가 지금까지 찾던 안전한 any 타입입니다! 하지만 이 정의에는 문제가 하나 있습니다. 바로 타입스크립트에는 forsome과 같은 직접적으로 존재 양화사를 나타내는 방법이 없다는 점입니다. 위 SafeAny는 우리의 상상 속 타입 시스템에는 존재하나 타입스크립트의 타입 시스템에는 존재하지 않지요.

하지만 걱정 마세요, 존재 양화사는 보편 양화사를 통해 표현될 수 있습니다. 제네릭이란 이름으로 타입스크립트에 있는 우리의 친구를 통해서요!

보편 양화사로 존재 양화사를 표현하는 방법

여기, 존재 양화사로 정의된 함수가 있습니다.

const discard: (_: <forsome A>A) => undefined
  = _ => undefined;

이 함수의 타입을 무한한 합 타입으로 풀어서 보면

const discard: (_: number | boolean | string | ...) => undefined 
  = _ => undefined;

discard 함수는 number 타입이나 boolean 타입이나 string 타입이나 … 타입의 값을 받아서 undefined 타입의 값으로 바꾸는 함수, 즉 어떤 타입의 값이든 전부 undefined 타입의 값으로 바꾸는 함수라는 것을 알 수 있습니다.

discard 함수의 구현만 보면 이는 더 명백하게 드러납니다.

_ => undefined

여기, 이번에는 제네릭으로 정의된 함수가 있습니다.

const discard2: <A>(_: A) => undefined
  = _ => undefined;

제네릭을 이용한 선언은 보편 양화사를 이용한 선언으로도 볼 수 있으니 위 코드를 보편 양화사를 이용한 코드로 바꾸어 보겠습니다.

const discard2: <forall A>(_: A) => undefined
  = _ => undefined;

또한 보편 양화사를 통해 표현된 타입은 무한한 교차 타입으로 표현할 수도 있으니 다시 코드를 바꾸어 보겠습니다.

const discard2: 
  & ((a: number) => undefined)
  & ((a: boolean) => undefined)
  & ((a: string) => undefined)
  & ... 
  = _ => undefined;

바꾼 코드를 보면 discard2 함수가 number 타입의 값을 받아 undefined 타입의 값으로 바꾸는 함수이면서, boolean 타입의 값을 받아 undefined 타입의 값으로 바꾸는 함수이고 … 타입의 값을 받아 undefined 타입의 값으로 바꾸는 함수임을 알 수 있습니다. 그리고 이는 곧 discard2 함수가 어떤 타입의 값이든 전부 undefined 타입의 값으로 바꾸는 함수라는 말과 같지요.

이번에도 discard2 함수의 구현만 보면 이는 더 명백하게 드러납니다.

_ => undefined

지금까지 보셨다시피, number 타입이나 boolean 타입이나 string 타입이나 … 타입의 값을 받아서 undefined 타입의 값으로 바꾸는 함수의 타입은 number 타입의 값을 받아 undefined 타입의 값으로 바꾸는 함수이면서 … 타입의 값을 받아 undefined 타입의 값으로 바꾸는 함수의 타입과 같습니다. 즉, existential type의 값을 받는 함수는 그 타입을 구성하는 모든 타입에 대해 정의된 함수와 같다는 거지요.

이는 아래와 같이 일반화하여 표현할 수 있으며, 이것이 바로 존재 양화사를 보편 양화사로 표현하는 방법 중 하나입니다.

// T는 임의의 제네릭 타입
<forall B>(x: <forsome A>T<A>) => B = <forall B, forall A>(x: T<A>) => B

심화 문제 #7
<forall B, forsome A>(x: A) => B 타입을 보편 양화사만 사용해서 표현해 보세요.


좋아요, 이제 존재 양화사를 보편 양화사로 대체하는 방법을 알아냈으니 SafeAny의 정의에서 존재 양화사를 제거할 수 있겠지요? 유감이지만, 아닙니다. SafeAny의 정의에서의 존재 양화사는 매개변수의 타입을 나타내는 데 쓰인 게 아니기 때문에 우리가 알아낸 방법으로 제거할 수 없습니다.

그렇다고 낙담하지는 마세요, 해결책이 있습니다. 바로 SafeAny의 값이 일반적인 값이 아니라, 컨티뉴에이션(continuation)이 되게 만드는 방법입니다.

값을 표현하는 함수

컨티뉴에이션은 값을 표현하는 함수로, 구체적으로는 함수를 받아 자신이 표현하는 값에 그 함수를 적용하는 함수입니다. 놀랍게도, 어떤 값의 컨티뉴에이션은 그 값과 본질적으로 같습니다. 왜냐하면 어떤 값과 그 값의 컨티뉴에이션은 모두 같은 목적으로 쓰일 수 있으며, 한 쪽이 다른 쪽으로 변환될 수도 있기 때문입니다.

예를 들어, 11의 컨티뉴에이션은 다음과 같이 정의할 수 있습니다.

const one = 1;
const contOfOne = f => f(1);

one + 1 === contOfOne(n => n + 1) // 같은 연산을 수행할 수도 있습니다
one === contOfOne(n => n) && (n => f => f(n))(one)(n => n) === contOfOne(n => n) // 한 쪽이 다른 쪽 표현으로 변환될 수도 있습니다

심화 문제 #8
어떤 값을 받아 그 값의 컨티뉴에이션을 만드는 함수 to와 컨티뉴에이션을 받아 그 컨티뉴에이션이 표현하는 값을 꺼내는 함수 from을 만들어 보시고, 만든 두 함수를 각기 다른 순서로 합성해 함수 id1id2를 만들어 보세요. id1id2는 각각 어떤 함수인가요?


SafeAny의 값이 모든 타입의 값에서 모든 타입의 값의 컨티뉴에이션이 되면 SafeAny의 정의를 <forall R>(f: (x: <forsome A>A) => R) => R로 바꿀 수 있고, 이 타입의 존재 양화사는 앞서 우리가 알아낸 방법을 통해 아래와 같이 보편 양화사로 치환할 수 있습니다!

type SafeAny = <forall R>(f: <forall A>(x: A) => R) => R

제네릭과 보편 양화사는 같으므로 보편 양화사를 표현하기 위해 사용한 의사 코드인 forall을 제거해 올바른 타입스크립트 코드로 만들어 줍시다.

type SafeAny = <R>(f: <A>(x: A) => R) => R

이렇게 우리의 안전한 any, SafeAny가 만들어졌습니다! 하지만 아직 모든 일이 끝난 건 아닙니다. SafeAny의 값이 모든 타입의 값이 아닌 모든 타입의 값의 컨티뉴에이션이기 때문에, 모든 타입의 값을 컨티뉴에이션으로 바꿔 주는 함수가 필요합니다.

모든 타입의 값을 컨티뉴에이션으로 바꿔 주는 함수는 아래와 같이 정의할 수 있습니다.

const safeAny: (x: <forsome A>A) => SafeAny
  = x => f => f(x);

이 함수의 존재 양화사를 보편 양화사로 치환하는 일을 마지막으로, 안전한 any가 완성됩니다.

type SafeAny = <R>(f: <A>(x: A) => R) => R

const safeAny: <A>(x: A) => SafeAny
  = x => f => f(x);

심화 문제 #9
SafeAny의 eliminator를 만들고, 커링해 보세요. 어떤 함수가 보이시나요?

심화 문제 #10
() => string 타입의 toString 메서드가 있어 문자열로 바꿀 수 있는 모든 값을 담는 타입, HasShow를 구현해 보세요.

마무리

이번 글에서는 안전한 any 타입을 만들어 보며 보편 양화사와 존재 양화사에 대해 알아보았습니다. 일반적인 프로그래밍에서는 잘 쓰이지 않고 타입 레벨 프로그래밍에서나 주로 쓰이는, 많은 분들이 생소해 하실 법한 개념이라 최대한 쉽게 설명하고자 노력해 보았는데 그래도 어려운 부분이 남아있는 거 같아 읽으시는 내내 힘들지는 않으셨는지 걱정되네요.

이미 알고 계신 분도 있으시겠지만 사실 이번에 만든 안전한 any 타입은 타입스크립트에 이미 unknown 이란 이름으로 구현되어 있습니다. 심지어 더 사용하기에 편한 형태로요. 물론 그렇다고 해서 지금껏 공부한 개념들이 무용지물이 되는 것은 아닙니다. Existential type으로 탑 타입을 만드는 일만 할 수 있는 게 아니라 타입을 통해 값의 소코프를 결정하는 일(a.k.a ST Trick)이나, 정적 타입 시스템에서 동적 타이핑을 구현하는 일과 같은 재미있는 일들을 많이 할 수 있거든요. 시간 나실때 이런 것들을 직접 찾아보시면서 만들어 보시는 것도 좋을 거 같습니다.

그럼 저는 이만 여기서 글을 마무리하도록 하겠습니다. 긴 글 읽어주셔서 감사합니다.

읽을거리

  • Thinking with Types
    타입 레벨 프로그래밍 책입니다. 대수 타입에서 시작해 의존 타입까지 다룹니다. 이론적인 측면보다는 하스켈을 통한 실용적인 측면에서 이야기를 펼쳐나가기 때문에 논리학이나 타입 이론을 잘 모르는 독자도 큰 문제 없이 읽을 수 있다는 장점이 있습니다.
  • Existential type-curry
    보편 양화사로 존재 양화사를 표현하는 법에 대한 글입니다. 수학적인 증명을 포함해, 더 엄밀하고 정확한 설명이 담겨 있습니다.
  • Quantified Types as Products and Sums
    각 양화사로 표현된 타입을 각각 무한한 곱 타입과 합 타입으로 보는 관점에 대한 토막글입니다.
  • Existential vs. Universally quantified types in Haskell
    존재 양화사로 정의된 타입과 보편 양화사로 정의된 타입의 차이를 묻는 스택오버플로 질문글입니다. 두 답변이 있는데 둘 다 존재 양화사에 대해서 잘 설명하고 있으니 더 알고 싶으시다면 읽어보셔도 좋은 글입니다.

Personal blog of Jaewon Seo.
I believe that knowledge becomes valuable only when we share it with others.